Skip to content

FEATURE: expose V8 ScriptCompiler::CachedData via Context#compile#413

Closed
ursm wants to merge 4 commits into
rubyjs:mainfrom
ursm:feature/cached-data-411
Closed

FEATURE: expose V8 ScriptCompiler::CachedData via Context#compile#413
ursm wants to merge 4 commits into
rubyjs:mainfrom
ursm:feature/cached-data-411

Conversation

@ursm

@ursm ursm commented May 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements #411 — exposes V8's ScriptCompiler::CachedData so callers can persist per-script bytecode cache and skip re-parsing large bundles on subsequent processes.

# First process: produce the cache
ctx = MiniRacer::Context.new
script = ctx.compile(File.read("bundle.js"), filename: "bundle.js", produce_cache: true)
File.binwrite("bundle.js.cache", script.cached_data) if script.cached_data
script.run

# Later process: restore from blob, skip the parse step
cached = File.binread("bundle.js.cache")
ctx = MiniRacer::Context.new
script = ctx.compile(File.read("bundle.js"), filename: "bundle.js", cached_data: cached)
script.run

API surface

  • MiniRacer::Context#compile(source, filename:, cached_data:, produce_cache:)MiniRacer::Script
  • Script#run — executes the compiled script; safe to call multiple times
  • Script#cached_data — bytecode blob (nil when the supplied cached_data: was accepted; populated on initial compile or after rejection, only when produce_cache: true was set)
  • Script#cache_rejected? — boolean for cache-key invalidation telemetry
  • Script#dispose / Script#disposed? — eager handle release
  • MiniRacer::V8_CACHED_DATA_VERSION_TAG — module-level constant (populated on first Context.new) wrapping v8::ScriptCompiler::CachedDataVersionTag(); mix into cache keys so a libv8-node bump invalidates blobs automatically

Safety constraints

Found while wiring this into a real embedder (capybara-simulated driving Discourse). All three are documented in the README; the first two are also enforced at runtime.

  1. produce_cache: defaults to false. Passing true from inside a host-fn callback raises MiniRacer::RuntimeError. V8's CreateCodeCache walks live isolate state and corrupts the parser when re-entered from a JS → Ruby → JS frame; standalone repro at https://github.com/rubyjs/mini_racer/issues — TODO. Warm the cache from the top level instead.
  2. Cross-process reuse requires byte-identical snapshot data on both sides. MiniRacer::Snapshot.new(src).dump is non-deterministic across processes, so feeding the same source string to two Snapshot.new calls produces different blobs and V8 rejects every cached_data crossing that boundary. Use Snapshot#dump → persist → Snapshot.load(bytes) instead.
  3. Cross-process reuse is incompatible with Platform.set_flags!(:single_threaded). V8's single-threaded mode embeds process-local state in the cache blob, so cached_data is always rejected when consumed in a fresh process. Same-process reuse (e.g. a Context pool) still works. Embedders that need both will need to disable :single_threaded for the cache-producing / cache-consuming path.

Design notes

Context dispose ordering: State::~State() walks st.scripts and resets each v8::Persistent<v8::Script> under the existing Locker/Isolate::Scope before isolate->Dispose(). Handle table is owned per-State.

Concurrency: compile/run/dispose RPCs go through the existing rendezvous mutex path; the handle table is only touched from the V8 thread. The new State::in_callback counter is incremented in v8_api_callback (also V8-thread-only) and read inside v8_compile; no cross-thread access, no atomic needed.

GC finalizer trade-off: script_free does NOT send a dispose RPC — taking rr_mtx from a Ruby finalizer thread risks deadlock. Handles freed via finalizer rely on State::~State() walking the table at isolate teardown. Long-lived Contexts with many short-lived Scripts will accumulate handles until Context#dispose. Documented in README; Script#dispose is available for eager release.

CachedData buffer policy: input blob uses BufferNotOwned pointing at the ValueDeserializer's ArrayBuffer backing store (valid for the v8_compile call), avoiding a copy of potentially MB-sized blobs.

Packet protocol: new tags 'K' (compile), 'R' (run), 'D' (dispose) added to dispatch1. 'C' was already taken by call, hence 'K' for compile.

Refs #411.

@ursm

ursm commented May 18, 2026

Copy link
Copy Markdown
Contributor Author

For sequencing context: #412 (Module API) is the next planned PR but I'm holding it back until this one lands. The two share a lot of C++ surface (handle table, packet protocol, dispose ordering) so iterating patterns here once will be cheaper than rebasing #412 twice. Flagging in case it helps frame the review.

@ursm ursm force-pushed the feature/cached-data-411 branch 5 times, most recently from a66648a to f6eaa25 Compare May 30, 2026 07:58
@ursm ursm changed the title FEATURE: expose V8 ScriptCompiler::CachedData via Context#compile FEATURE: add Context#compile returning a Script handle May 30, 2026
Adds Context#compile returning a MiniRacer::Script handle that can be
re-run multiple times and exposes V8's per-script bytecode cache.

Callers pass `cached_data:` to skip re-parsing on subsequent processes
and opt in to `produce_cache: true` to read the freshly produced blob
back via `script.cached_data` for persistence.

The MiniRacer::V8_CACHED_DATA_VERSION_TAG constant exposes V8's
CachedDataVersionTag() so callers can invalidate their cache when
libv8-node is bumped.

Safety constraints (documented in README and CHANGELOG):

* produce_cache defaults to false; passing true from inside a host-fn
  callback raises MiniRacer::RuntimeError. V8's CreateCodeCache walks
  live isolate state and corrupts the parser when re-entered from a
  JS->Ruby->JS frame; warm the cache from the top level instead.
* Cross-process reuse requires both processes to load byte-identical
  snapshot data via Snapshot#dump / Snapshot.load. Snapshot.new(src)
  is non-deterministic across processes, so feeding the same source
  string to both sides is not enough — the cache will be rejected.
* Cross-process reuse is incompatible with
  Platform.set_flags!(:single_threaded). V8's single-threaded mode
  embeds process-local state in the cache blob; same-process reuse
  still works.

TruffleRuby ships a shim that falls back to source replay since
GraalJS has no equivalent per-script cache reachable from
Polyglot::InnerContext.

Refs rubyjs#411.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@ursm ursm force-pushed the feature/cached-data-411 branch from f6eaa25 to 0329ecc Compare May 30, 2026 09:12
@ursm ursm changed the title FEATURE: add Context#compile returning a Script handle FEATURE: expose V8 ScriptCompiler::CachedData via Context#compile May 30, 2026
ursm added a commit to ursm/mini_racer that referenced this pull request May 30, 2026
Combines feature/module-api (PR rubyjs#421) with PR rubyjs#413's CachedData work
for Discourse smoke-testing. Experimental — not for merge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ursm and others added 3 commits May 31, 2026 15:51
Three correctness fixes to the CachedData implementation:

* Script handle leak. st.scripts stored each compiled script as a default-
  traits v8::Persistent, whose destructor does NOT Reset (kResetInDestructor
  is false), so v8_dispose_script's erase() and ~State's clear() freed only
  the wrapper and leaked the global handle — pinning the compiled script
  until isolate teardown. Script#dispose therefore reclaimed nothing, exactly
  the long-lived-Context / many-short-lived-scripts case the feature targets.
  Store v8::Global<v8::Script> instead (its destructor Reset()s), matching the
  module-handle approach. Adds a regression test that asserts dispose keeps
  the global-handle table flat (it fails against the Persistent version with
  disposed==retained growth).

* Timeout-safe result array. v8_compile runs under the watchdog, so a timeout
  can leave the isolate terminating while it populates the reply array;
  .Check() on Object::Set would then abort the process. Use the goto-fail
  idiom so the fail path cancels termination and replies a TERMINATED_ERROR.

* next_script_id overflow + cached_data length. Refuse to wrap the id at
  INT32_MAX instead of invoking signed-overflow UB, and feed RSTRING_LEN
  (not RSTRING_LENINT) to ser_uint8array, matching the rest of the file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Context#compile on TruffleRuby is a source-replay shim with no parse-only
mode, so test_compile_filename_in_parse_error / test_compile_invalid_source
(which call compile without run inside assert_raises(ParseError)) cannot
raise at compile time and were failing the TruffleRuby CI job. Skip them
there; the syntax error still surfaces from Script#run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
v8_compile inserted the v8::Global<v8::Script> into st.scripts before
populating the reply array. If one of the result->Set() calls bailed (e.g.
a watchdog timeout left the isolate terminating), the goto-fail path replied
an error, so the Ruby side never received the id and could never dispose it —
orphaning the handle until Context teardown. Build the reply array first and
register the handle only once every Set has succeeded.

Also harden the dispose regression test: compare with multiplication instead
of `retained_growth / 2` (an integer-division boundary could collapse the
bound to 0) and skip when the build doesn't report used_global_handles_size.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ursm ursm closed this Jun 5, 2026
ursm added a commit to ursm/mini_racer that referenced this pull request Jun 5, 2026
…M/realm code

The fork's load_module_graph / reset_realm work rewrote the module and
script bookkeeping, so the five edge-fixes that landed on the upstream
PR topic branches (rubyjs#413 a73c8db, 9cfb8b7; rubyjs#421 d15e152, f85b55e,
e8a6e24) could not be cherry-picked blind. Re-applied against the
refactored code:

- scripts map: unique_ptr<Persistent<Script>> -> Global<Script>. The
  default-traits Persistent destructor is a no-op, so erase()/clear()
  leaked the global handle until isolate teardown, silently defeating
  Script#dispose. Global's destructor Resets, so the handle is freed
  eagerly. Updated the v8_run deref and the reset_realm clear.

- v8_compile: guard next_script_id against INT32_MAX; build the reply
  array via the goto-fail idiom (Set().FromMaybe(false)) instead of
  .Check() so a watchdog termination replies TERMINATED_ERROR rather
  than aborting the process; register the script handle only after the
  reply is built so a failed Set can't orphan an undisposable handle.

- v8_module_namespace: surface an errored module's own exception, and
  require kEvaluated && !IsGraphAsync() before reading the namespace —
  reading a TDZ/async-pending namespace makes the serializer hit a
  throwing accessor that V8 turns into an unrecoverable process abort.

- host_import_module_dynamically_callback: reject an already-evaluated
  async module (top-level await) before serializing its namespace.

- init_import_meta_object: scope the per-entry Locals that
  module_filename() materializes while scanning.

- v8_compile_module: same INT32_MAX guard, goto-fail reply build, and
  register-after-reply ordering as v8_compile (the a73c8db/9cfb8b7
  fixes applied to the module twin, caught in review).

Full suite 208 + single_threaded 12 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ursm added a commit to ursm/mini_racer that referenced this pull request Jun 5, 2026
…ture

Combines feature/module-api (PR rubyjs#421) with PR rubyjs#413's CachedData work
for Discourse smoke-testing. Experimental — not for merge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ursm added a commit to ursm/mini_racer that referenced this pull request Jun 5, 2026
Renames the gem to mini_racer-csim so csim can depend on a released gem instead
of pinning a git ref. The library is unchanged for consumers: still required as
"mini_racer" and exposing the MiniRacer module (drop-in). Only packaging moves:
gemspec name/metadata/homepage point at the fork, the native-extension loader
resolves require_paths under the new spec name (falling back to the upstream
name and to default paths), and Rakefile/Gemfile reference the renamed gemspec.

This branch (csim) is the fork's main: upstream mini_racer (incl. rubyjs#427/rubyjs#425,
merged here) plus the browser-fidelity work — CachedData/rubyjs#413, ES Module API/rubyjs#421,
GVL-release/rubyjs#424, nested-watchdog/rubyjs#423, host_namespace/rubyjs#426, reset_realm +
warm-compile, load_module_graph + URL registry. Full suite + single-threaded green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ursm added a commit to ursm/mini_racer that referenced this pull request Jun 5, 2026
…M/realm code

The fork's load_module_graph / reset_realm work rewrote the module and
script bookkeeping, so the five edge-fixes that landed on the upstream
PR topic branches (rubyjs#413 a73c8db, 9cfb8b7; rubyjs#421 d15e152, f85b55e,
e8a6e24) could not be cherry-picked blind. Re-applied against the
refactored code:

- scripts map: unique_ptr<Persistent<Script>> -> Global<Script>. The
  default-traits Persistent destructor is a no-op, so erase()/clear()
  leaked the global handle until isolate teardown, silently defeating
  Script#dispose. Global's destructor Resets, so the handle is freed
  eagerly. Updated the v8_run deref and the reset_realm clear.

- v8_compile: guard next_script_id against INT32_MAX; build the reply
  array via the goto-fail idiom (Set().FromMaybe(false)) instead of
  .Check() so a watchdog termination replies TERMINATED_ERROR rather
  than aborting the process; register the script handle only after the
  reply is built so a failed Set can't orphan an undisposable handle.

- v8_module_namespace: surface an errored module's own exception, and
  require kEvaluated && !IsGraphAsync() before reading the namespace —
  reading a TDZ/async-pending namespace makes the serializer hit a
  throwing accessor that V8 turns into an unrecoverable process abort.

- host_import_module_dynamically_callback: reject an already-evaluated
  async module (top-level await) before serializing its namespace.

- init_import_meta_object: scope the per-entry Locals that
  module_filename() materializes while scanning.

- v8_compile_module: same INT32_MAX guard, goto-fail reply build, and
  register-after-reply ordering as v8_compile (the a73c8db/9cfb8b7
  fixes applied to the module twin, caught in review).

Full suite 208 + single_threaded 12 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ursm ursm deleted the feature/cached-data-411 branch June 6, 2026 00:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant